Row


Best moment

Mercredi 4 Mars @ 18:00

Sea quality (best moment)

Highest wave

3.1 m

Row


Wave size (mean) over time

Wind speed over time

Row


Summary table

---
title: "Dashboard_surf"
author: "DOMINGO MARCELLIN Giovanni"
date: "2026-01-09"
output:
  flexdashboard::flex_dashboard:
    orientation: rows
    vertical_layout: fill
    theme: flatly
    source_code: embed
---

```{r setup-ui, include=FALSE}

library(plotly)
library(ggplot2)
library(dplyr)
library(DT)
library(flexdashboard)
library(htmltools)

URL <- "https://www.surf-report.com/meteo-surf/lacanau-s1043.html"

# Force working dir = folder of this Rmd (critical in render)
input_file <- knitr::current_input()
root_dir <- if (!is.null(input_file) && nzchar(input_file)) {
  dirname(normalizePath(input_file))
} else {
  getwd()
}
knitr::opts_knit$set(root.dir = root_dir)
setwd(root_dir)

# Paths
PY_SCRIPT <- normalizePath(file.path(root_dir, "../python/run_surf_scrap.py"), mustWork = FALSE)
CSV_OUT   <- normalizePath(file.path(root_dir, "../data/data_surf.csv"), mustWork = FALSE)
LOG_FILE  <- file.path(root_dir, "python_run.log")

if (!file.exists(PY_SCRIPT)) {
  stop("Python script not found: ", PY_SCRIPT,
       "\nPut run_surf_scrap.py in the SAME folder as this dashboard.Rmd.")
}

PYTHON_EXE <- Sys.which("python")
if (PYTHON_EXE == "") {
  stop("Python not found in PATH. Set PYTHON_EXE manually if needed.")
}

# Dependency check (NO installs during render)
code <- "import pandas, requests; print('OK')"
chk <- system2(PYTHON_EXE, args = c("-c", shQuote(code)), stdout = TRUE, stderr = TRUE)
if (!any(grepl("^OK$", chk))) {
  stop("Python dependencies missing. Install once: python -m pip install pandas requests")
}
```

```{r dashboard-css, echo=FALSE}

htmltools::tags$style(htmltools::HTML("
/* Réduit les marges/paddings globaux */
.section.level2 { padding-top: 6px; }
.chart-wrapper { padding: 8px 10px 10px 10px; }

/* ValueBox: plus compact + texte mieux aligné */
.value-box { 
  border-radius: 10px; 
  box-shadow: 0 1px 6px rgba(0,0,0,0.08);
}
.value-box .value { font-size: 34px; font-weight: 700; }
.value-box .caption { font-size: 14px; opacity: 0.9; }

/* Tables DT: police + densité */
.dataTables_wrapper { font-size: 13px; }
table.dataTable tbody td { padding: 6px 8px; }
"))

```

```{r doc-modal, echo=FALSE}
library(htmltools)

# 1) HTML de la modal (Bootstrap)
doc_modal <- tags$div(
  class = "modal fade", id = "docModal", tabindex = "-1",
  role = "dialog", `aria-labelledby` = "docModalLabel", `aria-hidden` = "true",
  tags$div(
    class = "modal-dialog modal-lg", role = "document",
    tags$div(
      class = "modal-content",
      tags$div(
        class = "modal-header",
        tags$h4(class = "modal-title", id = "docModalLabel", "Documentation"),
        tags$button(
          type = "button", class = "close", `data-dismiss` = "modal", `aria-label` = "Close",
          tags$span(`aria-hidden` = "true", HTML("&times;"))
        )
      ),
      tags$div(
        class = "modal-body", style = "font-size:14px; line-height:1.45;",

        tags$h4("Objective"),
        tags$p(
          "This dashboard provides a decision-oriented view of the next-week surf forecast for Lacanau. ",
          "It highlights the best time slot to surf, the highest expected wave conditions, ",
          "wave and wind time series, and a summary table of forecasts."
        ),
        
        # --- Source code (GitHub) ---
        tags$h4("Source code (GitHub)"),
        tags$p(HTML(
          "Repository: <a href='https://github.com/echo-charbel/surf-dashboard-r-python.git' target='_blank'>https://github.com/echo-charbel/surf-dashboard-r-python.git</a>"
        )),

        tags$h4("How to run"),
        tags$ol(
          tags$li(HTML("Place <code>dashboard.Rmd</code> and <code>run_surf_scrap.py</code> in the same folder.")),
          tags$li(HTML("Ensure Python is available in PATH and install the required Python packages: <code>pandas</code> and <code>requests</code>.")),
          tags$li(HTML("Open <code>dashboard.Rmd</code> in RStudio and click <b>Knit</b> / <b>Render</b>.")),
          tags$li(HTML("During rendering, the dashboard runs the Python scraper, generates <code>data_surf.csv</code>, then computes KPIs and renders the visuals.")),
          tags$li(HTML("If the render fails, check <code>python_run.log</code> for Python output and errors."))
        ),

        tags$h4("Data source"),
        tags$p(HTML(
          "Forecasts are scraped from: <code>https://www.surf-report.com/meteo-surf/lacanau-s1043.html</code>.<br/>
           The Python script (<code>run_surf_scrap.py</code>) extracts the forecast table and writes <code>data_surf.csv</code>."
        )),

        tags$h4("Inputs / outputs"),
        tags$ul(
          tags$li(HTML("<b>Input:</b> Surf-Report HTML page (URL).")),
          tags$li(HTML("<b>Output:</b> <code>data_surf.csv</code> generated by the Python script.")),
          tags$li(HTML("<b>Logs:</b> <code>python_run.log</code> stores Python stdout/stderr to help troubleshooting."))
        ),

        tags$h4("Expected file structure"),
        tags$p("The dashboard expects the following files to be available at render time:"),
        tags$ul(
          tags$li(tags$code("dashboard.Rmd")),
          tags$li(tags$code("run_surf_scrap.py")),
          tags$li(HTML("<code>data_surf.csv</code> (generated at runtime)")),
          tags$li(HTML("<code>python_run.log</code> (generated at runtime)"))
        ),

        tags$h4("Dependencies"),
        tags$p(HTML(
          "<b>R:</b> flexdashboard, ggplot2, plotly, DT, dplyr, htmltools<br/>
           <b>Python:</b> pandas, requests (Python must be available in PATH)"
        )),

        tags$h4("Pipeline"),
        tags$ol(
          tags$li(HTML("Run the Python scraper (<code>run_surf_scrap.py</code>) to generate <code>data_surf.csv</code>.")),
          tags$li("Load the CSV into R and validate required columns."),
          tags$li(HTML(
            "Parse and transform raw strings into numeric variables:
             <ul>
               <li><code>Wave_Size_Mean</code>: mean of wave range (e.g., “0.8m - 0.7m” → 0.75).</li>
               <li><code>Wind_speed_num</code>: numeric wind speed (km/h).</li>
               <li><code>DateTime</code>: reconstructed timestamp from French day + time.</li>
             </ul>"
          )),
          tags$li("Compute KPIs (best moment, sea quality score, highest wave) and render charts and summary table.")
        ),

        tags$h4("Data dictionary (main variables)"),
        tags$ul(
          tags$li(HTML("<code>Date</code>, <code>Time</code>: forecast day label (French) and time slot.")),
          tags$li(HTML("<code>Wave_size</code>: raw wave range string scraped from the website (e.g., “0.8m - 0.7m”).")),
          tags$li(HTML("<code>Wind_speed</code>: raw wind speed string scraped from the website (e.g., “24 km/h”).")),
          tags$li(HTML("<code>Wind_direction</code>: raw wind direction label (e.g., “Ouest Nord Ouest”).")),
          tags$li(HTML("<code>Wave_Size_Mean</code>: numeric mean of the wave range (meters).")),
          tags$li(HTML("<code>Wind_speed_num</code>: numeric wind speed (km/h).")),
          tags$li(HTML("<code>DateTime</code>: reconstructed timestamp used for plotting.")),
          tags$li(HTML("<code>Quality</code>: surf quality score in 0–9 based on direction, waves, and wind speed."))
        ),

        tags$h4("Sea quality score (0–9)"),
        tags$p("The score is a simplified rule-based indicator (assignment-oriented), computed as:"),
        tags$ul(
          tags$li(HTML("<b>Direction:</b> +3 if wind direction contains “Nord”, else 0")),
          tags$li(HTML("<b>Waves:</b> +3 if ≤ 1.0m; +2 if ≤ 1.5m; +1 if ≤ 2.0m; else 0")),
          tags$li(HTML("<b>Wind speed:</b> +3 if ≤ 10; +2 if ≤ 25; +1 if ≤ 50; else 0"))
        ),
        tags$p(HTML(
          "Total: <code>Quality = score_dir + score_wave + score_wind</code> in {0,…,9}. ",
          "The gauge displays <code>100 × Quality/9</code>."
        )),

        tags$h4("Best moment selection rule"),
        tags$p(
          "The dashboard selects the time slot that maximizes the Quality score. ",
          "If a strict 'Nord' constraint is enabled, selection is performed only among slots whose wind direction contains “Nord”."
        ),

        tags$h4("Debugging / troubleshooting"),
        tags$ul(
          tags$li(HTML("If <code>data_surf.csv</code> is missing after render, inspect <code>python_run.log</code> (Python stdout/stderr).")),
          tags$li("If Python is not found, ensure it is installed and available in PATH (or configure the Python executable used by the dashboard)."),
          tags$li("If the website structure changes, the Python scraper may require updates (HTML selectors).")
        ),

        tags$h4("Limitations"),
        tags$p(
          "Quality is a simplified score and does not represent a physical ocean model. ",
          "Wind direction detection is based on text matching; wave ranges are summarized by their mean."
        )
      )
    )
  )
)

# 2) JS: ajoute un lien "Documentation" dans la navbar, à côté de "Source Code"
add_button_js <- tags$script(HTML("
(function(){
  function addDocLink(){
    var nav = document.querySelector('.navbar-nav');
    if(!nav) return;
    if(document.getElementById('docNavBtn')) return;

    var li = document.createElement('li');
    li.id = 'docNavBtn';
    li.innerHTML = '<a href=\"#\" data-toggle=\"modal\" data-target=\"#docModal\">Documentation</a>';

    // Insert before Source Code link if found, else append
    var items = nav.querySelectorAll('li');
    for (var i=0; i<items.length; i++){
      var a = items[i].querySelector('a');
      if(a && a.textContent && a.textContent.toLowerCase().indexOf('source code') !== -1){
        nav.insertBefore(li, items[i]);
        return;
      }
    }
    nav.appendChild(li);
  }

  document.addEventListener('DOMContentLoaded', function(){
    addDocLink();
    setTimeout(addDocLink, 300);
    setTimeout(addDocLink, 1200);
  });
})();
"))

tagList(doc_modal, add_button_js)
```

```{r}
cmd_args <- c(
shQuote(PY_SCRIPT),
"--url", shQuote(URL),
"--out", shQuote(CSV_OUT)
)

res <- tryCatch(
system2(PYTHON_EXE, args = cmd_args, stdout = TRUE, stderr = TRUE),
error = function(e) paste("system2() error:", conditionMessage(e))
)

writeLines(res, con = LOG_FILE)

if (!file.exists(CSV_OUT)) {
stop(
"CSV file not found after running Python.\nExpected: ", CSV_OUT,
"\nSee python log: ", normalizePath(LOG_FILE, winslash = "/", mustWork = FALSE),
"\n\nLast python output:\n", paste(tail(res, 80), collapse = "\n")
)
}
```

```{r}
cmd_args <- c(
  shQuote(PY_SCRIPT),
  "--url", shQuote(URL),
  "--out", shQuote(CSV_OUT)
)

res <- tryCatch(
  system2(PYTHON_EXE, args = cmd_args, stdout = TRUE, stderr = TRUE),
  error = function(e) paste("system2() error:", conditionMessage(e))
)

writeLines(res, con = LOG_FILE)

if (!file.exists(CSV_OUT)) {
  stop(
    "CSV file not found after running Python.\nExpected: ", CSV_OUT,
    "\nSee python log: ", normalizePath(LOG_FILE, winslash = "/", mustWork = FALSE),
    "\n\nLast python output:\n", paste(tail(res, 80), collapse = "\n")
  )
}
```

```{r}
raw <- read.csv(CSV_OUT, stringsAsFactors = FALSE)

# Normalize column names if needed
rename_map <- c(
  "Day" = "Date",
  "Hour" = "Time",
  "Waves_size" = "Wave_size",
  "WavesSize" = "Wave_size",
  "WindSpeed" = "Wind_speed",
  "WindDirection" = "Wind_direction",
  "Wind_Direction" = "Wind_direction"
)
for (nm in names(rename_map)) {
  if (nm %in% names(raw) && !(rename_map[[nm]] %in% names(raw))) {
    names(raw)[names(raw) == nm] <- rename_map[[nm]]
  }
}

needed <- c("Date", "Time", "Wave_size", "Wind_speed", "Wind_direction")
missing_cols <- setdiff(needed, names(raw))
if (length(missing_cols) > 0) {
  stop("Missing required columns in CSV: ", paste(missing_cols, collapse = ", "))
}

parse_fr_datetime <- function(date_fr, time_hm,
                              year = as.integer(format(Sys.Date(), "%Y"))) {
  s <- trimws(date_fr)
  parts <- strsplit(s, "\\s+")[[1]]
  if (length(parts) < 3) return(as.POSIXct(NA))

  day_num  <- parts[2]
  month_fr <- parts[3]

  months <- c(
    "Janvier" = 1,
    "Février" = 2, "Fevrier" = 2,
    "Mars" = 3,
    "Avril" = 4,
    "Mai" = 5,
    "Juin" = 6,
    "Juillet" = 7,
    "Août" = 8, "Aout" = 8,
    "Septembre" = 9,
    "Octobre" = 10,
    "Novembre" = 11,
    "Décembre" = 12, "Decembre" = 12
  )

  m <- months[[month_fr]]
  if (is.null(m)) return(as.POSIXct(NA))

  iso <- sprintf("%04d-%02d-%02d %s:00", year, as.integer(m), as.integer(day_num), time_hm)
  as.POSIXct(iso, tz = "")
}

# Wave mean from "0.8m - 0.7m" or "0.8 - 0.7"
extract_wave_mean <- function(x) {
  x <- gsub("m", "", x, fixed = TRUE)
  x <- gsub("\\s+", "", x)
  sp <- strsplit(x, "-")
  v1 <- suppressWarnings(as.numeric(sapply(sp, function(z) if (length(z) >= 1) z[1] else NA)))
  v2 <- suppressWarnings(as.numeric(sapply(sp, function(z) if (length(z) >= 2) z[2] else NA)))
  rowMeans(cbind(v1, v2), na.rm = TRUE)
}

# Wind speed from "3km/h" -> 3
extract_wind_speed <- function(x) {
  x <- gsub("km/h", "", x, fixed = TRUE)
  x <- gsub("\\s+", "", x)
  suppressWarnings(as.numeric(x))
}

# Build dataset (single, clean pipeline)
data <- raw %>%
  mutate(
    Wave_Size_Mean = extract_wave_mean(Wave_size),
    Wind_speed_num = extract_wind_speed(Wind_speed),
    DateTime       = as.POSIXct(unlist(mapply(parse_fr_datetime, Date, Time, SIMPLIFY = FALSE)),
                                origin = "1970-01-01", tz = "")
  ) %>%
  filter(!is.na(DateTime)) %>%
  arrange(DateTime) %>%
  mutate(
    score_dir  = ifelse(grepl("Nord", Wind_direction, ignore.case = TRUE), 3, 0),
    score_wave = ifelse(Wave_Size_Mean <= 1.0, 3,
                        ifelse(Wave_Size_Mean <= 1.5, 2,
                               ifelse(Wave_Size_Mean <= 2.0, 1, 0))),
    score_wind = ifelse(Wind_speed_num <= 10, 3,
                        ifelse(Wind_speed_num <= 25, 2,
                               ifelse(Wind_speed_num <= 50, 1, 0))),
    Quality = score_dir + score_wave + score_wind
  )

# --- Strict constraint: keep only slots with "Nord" in wind direction
data_nord <- data %>%
  dplyr::filter(grepl("Nord", Wind_direction, ignore.case = TRUE))

if (nrow(data_nord) == 0) {
  # Fallback if no "Nord" slots exist (optional)
  best_idx <- which.max(data$Quality)
  best_row <- data[best_idx, ]
} else {
  best_idx <- which.max(data_nord$Quality)
  best_row <- data_nord[best_idx, ]
}

highest_wave <- max(data$Wave_Size_Mean, na.rm = TRUE)
hw_rows      <- data %>% filter(Wave_Size_Mean == highest_wave)

quality_pct <- round(100 * best_row$Quality / 9)

tbl <- data %>%
  transmute(
    Day = Date,
    Time = Time,
    Wave_mean_m = round(Wave_Size_Mean, 2),
    Wind_kmh = round(Wind_speed_num, 0),
    Direction = Wind_direction
  )

# Plots (no ggplot title; the flexdashboard section titles are enough)
p1 <- ggplot(data, aes(x = DateTime, y = Wave_Size_Mean)) +
  geom_line(linewidth = 1) +
  geom_point(size = 1.2, alpha = 0.7) +
  labs(x = NULL, y = "Mean wave size (m)") +
  scale_x_datetime(date_breaks = "3 days", date_labels = "%d %b") +
  scale_y_continuous(expand = expansion(mult = c(0.02, 0.05))) +
  theme_minimal(base_size = 12) +
  theme(
    panel.grid.minor = element_blank(),
    axis.text.x = element_text(angle = 35, hjust = 1)
  )

p2 <- ggplot(data, aes(x = DateTime, y = Wind_speed_num)) +
  geom_line(linewidth = 1) +
  geom_point(size = 1.2, alpha = 0.7) +
  labs(x = NULL, y = "Wind speed (km/h)") +
  scale_x_datetime(date_breaks = "3 days", date_labels = "%d %b") +
  scale_y_continuous(expand = expansion(mult = c(0.02, 0.05))) +
  theme_minimal(base_size = 12) +
  theme(
    panel.grid.minor = element_blank(),
    axis.text.x = element_text(angle = 35, hjust = 1)
  )
```

## Row {data-height="230"}

------------------------------------------------------------------------

### Best moment {data-width="7"}

```{r}
valueBox(
  value = paste0(best_row$Date, " @ ", best_row$Time),
  caption = paste0(
    "Quality ", best_row$Quality, "/9",
    " | Wave ", round(best_row$Wave_Size_Mean, 2), " m",
    " | Wind ", best_row$Wind_speed_num, " km/h",
    " | ", best_row$Wind_direction
  ),
  icon  = "fa-star",
  color = "success"
)
```

### Sea quality (best moment) {data-width="2"}

```{r}
gauge(
  value  = quality_pct,
  min    = 0,
  max    = 100,
  symbol = "%",
  label  = "Sea quality"
)
```

### Highest wave {data-width="3"}

```{r}
valueBox(
  value = paste0(round(highest_wave, 2), " m"),
  caption = paste0("Scheduled for ", hw_rows$Date[1]),
  icon  = "fa-arrow-up",
  color = "info"
)
```

## Row {data-height="420"}

------------------------------------------------------------------------

### Wave size (mean) over time {data-width="6"}

```{r}
ggplotly(p1, tooltip = c("x", "y")) %>%
  layout(
    hovermode = "x unified",
    margin = list(l = 55, r = 15, t = 40, b = 70),
    xaxis = list(nticks = 6, tickangle = -35)
  )
```

### Wind speed over time {data-width="6"}

```{r}
ggplotly(p2, tooltip = c("x", "y")) %>%
  layout(
    hovermode = "x unified",
    margin = list(l = 55, r = 15, t = 40, b = 70),
    xaxis = list(nticks = 6, tickangle = -35)
  )
```

## Row {data-height="350"}

------------------------------------------------------------------------

### Summary table

```{r summary-table, echo=FALSE, message=FALSE, warning=FALSE}
DT::datatable(
  tbl,
  rownames = FALSE,
  class = "stripe hover compact",
  extensions = c("Scroller"),
  options = list(
    pageLength = 12,
    lengthMenu = c(8, 12, 20, 50),
    searching = TRUE,
    ordering = TRUE,

    autoWidth = FALSE,
    scrollX = TRUE,
    scrollY = "240px",
    scrollCollapse = TRUE,
    deferRender = TRUE,
    scroller = TRUE,

    dom = "lftip",

    initComplete = DT::JS(
      "function(settings, json){ setTimeout(() => { this.api().columns.adjust(); }, 50); }"
    )
  )
)
```